diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/(reports)/journeys | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)/journeys')
4 files changed, 640 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css new file mode 100644 index 0000000..63643f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css @@ -0,0 +1,267 @@ +.container { + width: 100%; + height: 100%; + position: relative; + + --journey-line-color: var(--base-color-6); + --journey-active-color: var(--primary-color); + --journey-faded-color: var(--base-color-3); +} + +.view { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: auto; + gap: 100px; + padding-right: 20px; +} + +.header { + margin-bottom: 20px; +} + +.stats { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 100%; +} + +.visitors { + font-weight: 600; + font-size: 16px; + text-transform: lowercase; +} + +.dropoff { + font-weight: 600; + color: var(--font-color-muted); + background: var(--base-color-2); + padding: 4px 8px; + border-radius: 5px; +} + +.num { + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: 50px; + height: 50px; + font-size: 16px; + font-weight: 700; + color: var(--base-color-1); + background: var(--base-color-12); + z-index: 1; + margin: 0 auto 20px; +} + +.column { + display: flex; + flex-direction: column; +} + +.nodes { + position: relative; + display: flex; + flex-direction: column; + height: 100%; +} + +.wrapper { + padding-bottom: 10px; +} + +.node { + position: relative; + cursor: pointer; + padding: 10px 20px; + background: var(--base-color-3); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 300px; + max-width: 300px; + height: 60px; + max-height: 60px; +} + +.node:hover:not(.selected) { + background: var(--base-color-4); +} + +.node.selected { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.active { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.node.selected .count { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.selected.active .count { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.name { + max-width: 200px; +} + +.line { + position: absolute; + bottom: 0; + left: -100px; + width: 100px; + pointer-events: none; +} + +.line.up { + bottom: 0; +} + +.line.down { + top: 0; +} + +.segment { + position: absolute; +} + +.start { + left: 0; + width: 50px; + height: 30px; + border: 0; +} + +.mid { + top: 60px; + width: 50px; + border-right: 3px solid var(--journey-line-color); +} + +.end { + width: 50px; + height: 30px; + border: 0; +} + +.up .start { + top: 30px; + border-top-right-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.up .end { + width: 52px; + bottom: 27px; + right: 0; + border-bottom-left-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.down .start { + bottom: 27px; + border-bottom-right-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.down .end { + width: 52px; + top: 30px; + right: 0; + border-top-left-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.flat .start { + left: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.flat .end { + right: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.start:before, +.end:before { + content: ""; + position: absolute; + border-radius: 100%; + border: 3px solid var(--journey-line-color); + background: var(--base-color-1); + width: 14px; + height: 14px; +} + +.line:not(.active) .start:before, +.line:not(.active) .end:before { + display: none; +} + +.up .start:before { + left: -8px; + top: -8px; +} + +.up .end:before { + right: -8px; + bottom: -8px; +} + +.down .start:before { + left: -8px; + bottom: -8px; +} + +.down .end:before { + right: -8px; + top: -8px; +} + +.flat .start:before { + left: -8px; + top: -8px; +} + +.flat .end:before { + right: -8px; + top: -8px; +} + +.line.active .segment, +.line.active .segment:before { + border-color: var(--journey-active-color); + z-index: 1; +} + +.column.active .line:not(.active) .segment { + border-color: var(--journey-faded-color); +} + +.column.active .line:not(.active) .segment:before { + display: none; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx new file mode 100644 index 0000000..3327a42 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx @@ -0,0 +1,294 @@ +import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { firstBy } from 'thenby'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks'; +import { File } from '@/components/icons'; +import { Lightning } from '@/components/svg'; +import { objectToArray } from '@/lib/data'; +import { formatLongNumber } from '@/lib/format'; +import styles from './Journey.module.css'; + +const NODE_HEIGHT = 60; +const NODE_GAP = 10; +const LINE_WIDTH = 3; + +export interface JourneyProps { + websiteId: string; + startDate: Date; + endDate: Date; + steps: number; + startStep?: string; + endStep?: string; +} + +export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { + const [selectedNode, setSelectedNode] = useState(null); + const [activeNode, setActiveNode] = useState(null); + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery<any>('journey', { + websiteId, + steps, + startStep, + endStep, + }); + + useEscapeKey(() => setSelectedNode(null)); + + const columns = useMemo(() => { + if (!data) { + return []; + } + + const selectedPaths = selectedNode?.paths ?? []; + const activePaths = activeNode?.paths ?? []; + const columns = []; + + for (let columnIndex = 0; columnIndex < +steps; columnIndex++) { + const nodes = {}; + + data.forEach(({ items, count }: any, nodeIndex: any) => { + const name = items[columnIndex]; + + if (name) { + const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name); + const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name); + + if (!nodes[name]) { + const paths = data.filter(({ items }) => items[columnIndex] === name); + + nodes[name] = { + name, + count, + totalCount: count, + nodeIndex, + columnIndex, + selected, + active, + paths, + pathMap: paths.map(({ items, count }) => ({ + [`${columnIndex}:${items.join(':')}`]: count, + })), + }; + } else { + nodes[name].totalCount += count; + } + } + }); + + columns.push({ + nodes: objectToArray(nodes).sort(firstBy('total', -1)), + }); + } + + columns.forEach((column, columnIndex) => { + const nodes = column.nodes.map( + ( + currentNode: { totalCount: number; name: string; selected: boolean }, + currentNodeIndex: any, + ) => { + const previousNodes = columns[columnIndex - 1]?.nodes; + let selectedCount = previousNodes ? 0 : currentNode.totalCount; + let activeCount = selectedCount; + + const lines = + previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => { + const fromCount = selectedNode?.paths.reduce((sum, path) => { + if ( + previousNode.name === path.items[columnIndex - 1] && + currentNode.name === path.items[columnIndex] + ) { + sum += path.count; + } + return sum; + }, 0); + + if (currentNode.selected && previousNode.selected && fromCount) { + arr.push([previousNodeIndex, currentNodeIndex]); + selectedCount += fromCount; + + if (previousNode.active) { + activeCount += fromCount; + } + } + + return arr; + }, []) || []; + + return { ...currentNode, selectedCount, activeCount, lines }; + }, + ); + + const visitorCount = nodes.reduce( + (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => { + if (!selectedNode) { + sum += totalCount; + } else if (!activeNode && selectedNode && selected) { + sum += selectedCount; + } else if (activeNode && active) { + sum += activeCount; + } + return sum; + }, + 0, + ); + + const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0; + const dropOff = + previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0; + + Object.assign(column, { nodes, visitorCount, dropOff }); + }); + + return columns; + }, [data, selectedNode, activeNode]); + + const handleClick = (name: string, columnIndex: number, paths: any[]) => { + if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) { + setSelectedNode({ name, columnIndex, paths }); + } else { + setSelectedNode(null); + } + setActiveNode(null); + }; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%"> + <div className={styles.container}> + <div className={styles.view}> + {columns.map(({ visitorCount, nodes }, columnIndex) => { + return ( + <div + key={columnIndex} + className={classNames(styles.column, { + [styles.selected]: selectedNode, + [styles.active]: activeNode, + })} + > + <div className={styles.header}> + <div className={styles.num}>{columnIndex + 1}</div> + <div className={styles.stats}> + <div className={styles.visitors} title={visitorCount}> + {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)} + </div> + </div> + </div> + <div className={styles.nodes}> + {nodes.map( + ({ + name, + totalCount, + selected, + active, + paths, + activeCount, + selectedCount, + lines, + }) => { + const nodeCount = selected + ? active + ? activeCount + : selectedCount + : totalCount; + + const remaining = + columnIndex > 0 + ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100) + : 0; + + const dropped = 100 - remaining; + + return ( + <div + key={name} + className={styles.wrapper} + onMouseEnter={() => + selected && setActiveNode({ name, columnIndex, paths }) + } + onMouseLeave={() => selected && setActiveNode(null)} + > + <div + className={classNames(styles.node, { + [styles.selected]: selected, + [styles.active]: active, + })} + onClick={() => handleClick(name, columnIndex, paths)} + > + <Row alignItems="center" className={styles.name} title={name} gap> + <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon> + <Text truncate>{name}</Text> + </Row> + <div className={styles.count} title={nodeCount}> + <TooltipTrigger + delay={0} + isDisabled={columnIndex === 0 || (selectedNode && !selected)} + > + <Focusable> + <div>{formatLongNumber(nodeCount)}</div> + </Focusable> + <Tooltip placement="top" offset={20} showArrow> + <Text transform="lowercase" color="ruby"> + {`${dropped}% ${formatMessage(labels.dropoff)}`} + </Text> + <Column> + <Text transform="lowercase"> + {`${remaining}% ${formatMessage(labels.conversion)}`} + </Text> + </Column> + </Tooltip> + </TooltipTrigger> + </div> + {columnIndex < columns.length && + lines.map(([fromIndex, nodeIndex], i) => { + const height = + (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) - + NODE_GAP; + const midHeight = + (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) + + NODE_GAP + + LINE_WIDTH; + const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name; + + return ( + <div + key={`${fromIndex}${nodeIndex}${i}`} + className={classNames(styles.line, { + [styles.active]: + active && + activeNode?.paths.find( + (path: { items: any[] }) => + path.items[columnIndex] === name && + path.items[columnIndex - 1] === nodeName, + ), + [styles.up]: fromIndex < nodeIndex, + [styles.down]: fromIndex > nodeIndex, + [styles.flat]: fromIndex === nodeIndex, + })} + style={{ height }} + > + <div className={classNames(styles.segment, styles.start)} /> + <div + className={classNames(styles.segment, styles.mid)} + style={{ + height: midHeight, + }} + /> + <div className={classNames(styles.segment, styles.end)} /> + </div> + ); + })} + </div> + </div> + ); + }, + )} + </div> + </div> + ); + })} + </div> + </div> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx new file mode 100644 index 0000000..14b8341 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -0,0 +1,67 @@ +'use client'; +import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { Journey } from './Journey'; + +const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; +const DEFAULT_STEP = 3; + +export function JourneysPage({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [steps, setSteps] = useState(DEFAULT_STEP); + const [startStep, setStartStep] = useState(''); + const [endStep, setEndStep] = useState(''); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Grid columns="repeat(3, 1fr)" gap> + <Select + items={JOURNEY_STEPS} + label={formatMessage(labels.steps)} + value={steps} + defaultValue={steps} + onChange={setSteps} + > + {JOURNEY_STEPS.map(step => ( + <ListItem key={step} id={step}> + {step} + </ListItem> + ))} + </Select> + <Column> + <SearchField + label={formatMessage(labels.startStep)} + value={startStep} + onSearch={setStartStep} + delay={1000} + /> + </Column> + <Column> + <SearchField + label={formatMessage(labels.endStep)} + value={endStep} + onSearch={setEndStep} + delay={1000} + /> + </Column> + </Grid> + <Panel height="900px" allowFullscreen> + <Journey + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + steps={steps} + startStep={startStep} + endStep={endStep} + /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx new file mode 100644 index 0000000..f6062a6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { JourneysPage } from './JourneysPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <JourneysPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Journeys', +}; |